package org.cjprods.ars;

import java.io.IOException;
import java.io.PrintWriter;
import java.lang.reflect.*;
import java.sql.*;
import java.util.Arrays;
import java.util.Map;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.stream.Stream;
import javax.json.*;
import javax.servlet.ServletContext;
import javax.servlet.ServletException;
import javax.servlet.http.*;
import org.cjprods.ars.annotations.arg;
import org.cjprods.ars.annotations.path;
import org.cjprods.ars.annotations.post;
import org.cjprods.ars.annotations.result;
import org.cjprods.ars.annotations.sql;
import org.cjprods.ars.annotations.token;

public class dispatcher extends HttpServlet
{
    @Override
    public String getServletInfo()
    {
        return "Automobile Renting System";
    }

    @Override
    protected void doGet(HttpServletRequest request,
                         HttpServletResponse response)
            throws ServletException, IOException
    {
        dispatch_request(request, response, getServletContext(), false);
    }

    @Override
    protected void doPost(HttpServletRequest request,
                          HttpServletResponse response)
            throws ServletException, IOException
    {
        dispatch_request(request, response, getServletContext(), true);
    }

    /**
    Dispatch the request by METHOD(POST or GET) and by whether the requested path
    is an API or an HTML.
    */
    private static void dispatch_request(HttpServletRequest request,
                                         HttpServletResponse response,
                                         ServletContext ctx,
                                         boolean by_post)
            throws ServletException, IOException
    {
        String request_path = request.getPathInfo();
        Map<String, String[]> args = request.getParameterMap();

        Method service = find_service(request_path, by_post);
        String result = utils.string(invoke_api(service, args));

        response.setContentType("application/json");
        try (PrintWriter out = response.getWriter()) {
            out.println(result);
        }
    }

    /**

    @param request_path NonNull
    @param by_post
    @return NonNull
    */
    private static Method find_service(
            String request_path, boolean by_post)
    {
        for (Method service: services.class.getDeclaredMethods()) {
            // must be public static
            int mods = service.getModifiers();
            if (!Modifier.isStatic(mods)) continue;
            if (!Modifier.isPublic(mods)) continue;

            // must match "access method" (POST or GET)
            if (by_post ^ service.isAnnotationPresent(post.class)) continue;

            // must match path
            path service_path = service.getDeclaredAnnotation(path.class);
            if (service_path == null) continue;
            if (!service_path.value().equals(request_path)) continue;

            return service;
        }

        try {
            return services.class.getDeclaredMethod("no_such_api");
        }
        catch (NoSuchMethodException | SecurityException ex) {
            Logger.getLogger(dispatcher.class.getName()).log(Level.SEVERE, null,
                                                             ex);
            throw new utils.failure("internal error");
        }
    }


    private static JsonObject invoke_api(Method service, Map<String, String[]> args)
    {
        try {
            JsonValue result;

            try (Connection conn = utils.database()) {
                String user = check_privilege(conn, service, get(args, "token"));
                result = invoke_api(service, args, user, conn);
            }
            catch (SQLException ex) {
                Logger.getLogger(dispatcher.class.getName()).log(Level.SEVERE, null, ex);
                throw new utils.failure("bad database: " + ex.getMessage());
            }

            JsonObjectBuilder jobj = Json.createObjectBuilder();
            if (result != null) jobj.add("result", result);
            return jobj.build();
        }
        catch (utils.failure ex) {
            return Json.createObjectBuilder()
                    .add("error", ex.message())
                    .build();
        }
    }

    private static JsonValue invoke_api(
            Method service, Map<String, String[]> args,
            String user, Connection conn)
    {
        try {
            JsonValue sql_result = execute_sql(
                    service.getDeclaredAnnotation(sql.class),
                    args, user, conn);
            Object[] api_args = make_api_args(service, sql_result, args);

            JsonValue result = (JsonValue)service.invoke(service, api_args);
            return (service.getReturnType() == void.class ? sql_result : result);
        }
        catch (IllegalArgumentException | IllegalAccessException ex) {
            Logger.getLogger(dispatcher.class.getName()).log(Level.SEVERE, null,
                                                             ex);
            throw new utils.failure("internal error");
        }
        catch (InvocationTargetException ex) {
            Logger.getLogger(dispatcher.class.getName()).log(Level.SEVERE, null,
                                                             ex);
            Throwable cause = ex.getCause();
            if (cause instanceof utils.failure) throw (RuntimeException)cause;
            throw new utils.failure("internal error");
        }
    }

    /**
    @param service_token NonNull
    @param role NonNull
    */
    private static void check_privilege(token service_token, String role)
    {
        String[] roles = service_token.roles();
        if (roles.length == 0) return;
        if (Arrays.asList(roles).contains(role)) return;
        throw new utils.failure("denied");
    }

    /**
    @param token: may be null
    @return user, may be null
    */
    private static String check_privilege(
            Connection conn, Method service, String token)
    {
        token service_token = service.getDeclaredAnnotation(token.class);
        if (service_token == null) return null;
        if (token == null) throw new utils.failure("denied");

        String[] seq_role = utils.unpack_token(token);          // check token validity
        String user = utils.user_from_seq(conn, seq_role[0]);   // check seq validity

        check_privilege(service_token, seq_role[1]);
        return user;
    }

    private static String get(Map<String, String[]> args, String name)
    {
        String[] values = args.get(name);
        if (values == null) return null;
        if (values.length != 1) return null;    // CAUTION: multiple value not allowed;
        return values[0];
    }

    private static String get(Map<String, String[]> args, String name, String error)
    {
        String result = get(args, name);
        if (result == null) throw new utils.failure(error);
        return result;
    }

    private static String get(JsonValue sql_result, String name)
    {
        // shall not be null or multi-value
        if (!(sql_result instanceof JsonObject))
            throw new utils.failure("internal error");

        JsonObject jobj = (JsonObject)sql_result;
        String result = jobj.getString(name);
        if (result == null) throw new utils.failure("internal error");
        return result;
    }

    /**

    @param s Nullable
    @return
        s.multi: return JsonArray
        !s.multi: one value: return JsonObject
                  no value or multiple value: return null
    */
    private static JsonValue execute_sql(
            sql s, Map<String, String[]> args, String user, Connection conn)
    {
        if (s == null) return null;

        JsonArrayBuilder jab = Json.createArrayBuilder();
        String[] values = make_sql_values(s.values(), args, user);

        try (PreparedStatement ps = utils.prepare(conn, s.func(), values)) {
            ResultSet rs = ps.executeQuery();
            if (s.results().length > 0)
                while (rs.next()) {
                    JsonObjectBuilder job = Json.createObjectBuilder();
                    int i = 1;
                    for (String result: s.results()) {
                        String value = rs.getString(i++);
                        if (value == null) job.addNull(result);
                        else job.add(result, value);
                    }
                    jab.add(job);
                }
        }
        catch (SQLException ex) {
            Logger.getLogger(dispatcher.class.getName()).log(Level.SEVERE, null,
                                                             ex);
            String msg;
            switch (ex.getSQLState()) {
                case "NOTFD": msg = "bad argument: " + ex.getMessage(); break;
                case "23502": msg = "internal error"; break;               // not null violation
                case "23503": msg = "bad argument: domain error"; break;   // foreign key violation
                case "23505": msg = "bad argument: not unique"; break;     // unique violation
                case "23514": msg = "bad argument: domain error"; break;   // check violation
                case "22P02": msg = "bad argument: domain error"; break;   // invalid enum value
                default: msg = "bad database: " + ex.getMessage();
            }
            throw new utils.failure(msg);
        }

        JsonArray jarr = jab.build();
        if (s.multi()) return jarr;
        if (jarr.isEmpty()) return null;
        if (jarr.size() == 1) return jarr.getJsonObject(0);
        throw new utils.failure("internal error");
    }

    private static String[] make_sql_values(
            String[] value_names, Map<String, String[]> args, String user)
    {
        return Stream.of(value_names)
                .map(name -> make_sql_value(name, args, user))
                .toArray(String[]::new);
    }

    /**

    @param value_name
        "#" -> user
        "*name" -> make_credential(make_sql_value("name"))
        "name" -> get(args, "name")
        "*#" -> make_credential(user)
    @param args
    @param user
    @return
    */
    private static String make_sql_value(
            String value_name, Map<String, String[]> args, String user)
    {
        if ("#".equals(value_name)) return user;

        if (value_name.startsWith("*")) {
            String value = make_sql_value(value_name.substring(1), args, user);
            if ("".equals(value)) throw new utils.failure("bad argument: empty secret");
            return utils.make_credential(value);
        }

        return get(args, value_name, "lack arguments");
    }

    /**
    Build an array of arguments that can be passed to Method::invoke.
    The array is built from parameter specification of a method
    and request-provided arguments.

    The mapping is done by finding the @arg annotation from parameters.

    null will be in the array if the argument is null or the parameter has no
    @arg annotation.

    @param params   Parameter specification of a method.
                    Usually comes from Method::getParameters()
    @param args     Arguments from request.
                    Usually comes from HttpServletRequest::getParameterMap()
    */
    private static Object[] make_api_args(
            Method service, JsonValue call_result,
            Map<String, String[]> args)
    {
        return Stream.of(service.getParameters())
                .map(param -> make_api_arg(param, call_result, args))
                .toArray();
    }

    /**
    Build an argument from parameter specification.
    @return NonNull
    */
    private static Object make_api_arg(
            Parameter param,
            JsonValue call_result,
            Map<String, String[]> args)
    {
        arg param_arg = param.getDeclaredAnnotation(arg.class);
        if (param_arg != null) return get(args, param_arg.value(), "lack arguments");

        result param_result = param.getDeclaredAnnotation(result.class);
        if (param_result != null) return get(call_result, param_result.value());

        throw new utils.failure("internal error");
    }
}
